From cad2b30fdd43dc8fb405c6fdf91eb46a378f821a Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Fri, 5 Dec 2025 21:44:54 +0900
Subject: (김준회) v2 오류수정 및 테스트페이지, 시딩스크립트 추가
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/[lng]/test/table-v2/page.tsx | 630 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 630 insertions(+)
 create mode 100644 app/[lng]/test/table-v2/page.tsx

(limited to 'app/[lng]/test/table-v2/page.tsx')

diff --git a/app/[lng]/test/table-v2/page.tsx b/app/[lng]/test/table-v2/page.tsx
new file mode 100644
index 00000000..e7fb5bdd
--- /dev/null
+++ b/app/[lng]/test/table-v2/page.tsx
@@ -0,0 +1,630 @@
+"use client";
+
+import * as React from "react";
+import { PaginationState, SortingState, ColumnFiltersState, GroupingState } from "@tanstack/react-table";
+import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table";
+import { TestProduct } from "@/db/schema/test-table-v2";
+import { productColumns, orderColumns } from "./columns";
+import { OrderWithDetails } from "./column-defs";
+import { 
+  getAllProducts, 
+  getProductTableData, 
+  getOrderTableData,
+  getProductTableDataWithGrouping,
+  GroupInfo,
+} from "./actions";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+// ============================================================
+// Reusable Loading Overlay Component
+// ============================================================
+
+function LoadingOverlay({ 
+  isLoading, 
+  children 
+}: { 
+  isLoading: boolean; 
+  children: React.ReactNode 
+}) {
+  return (
+    <div className="relative">
+      {children}
+      {isLoading && (
+        <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-[2px] transition-all duration-200">
+          <div className="flex items-center gap-2 px-4 py-2 bg-background rounded-lg shadow-lg border">
+            <Loader2 className="h-5 w-5 animate-spin text-primary" />
+            <span className="text-sm text-muted-foreground">Loading...</span>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+// ============================================================
+// Pattern 1: Client-Side Table
+// ============================================================
+
+function ClientSideTable() {
+  const [data, setData] = React.useState<TestProduct[]>([]);
+  const [isLoading, setIsLoading] = React.useState(true);
+
+  React.useEffect(() => {
+    const fetchData = async () => {
+      setIsLoading(true);
+      try {
+        const products = await getAllProducts();
+        setData(products);
+      } catch (error) {
+        console.error("Failed to fetch products:", error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchData();
+  }, []);
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center gap-2">
+          <CardTitle>Pattern 1: Client-Side</CardTitle>
+          <Badge variant="outline">fetchMode=&quot;client&quot;</Badge>
+        </div>
+        <CardDescription>
+          모든 데이터를 한 번에 받아와 클라이언트에서 필터링/정렬/페이지네이션/그룹핑 처리합니다.
+          <br />
+          <span className="text-muted-foreground">
+            적합: 데이터 1000건 이하, 빠른 인터랙션 필요 시
+          </span>
+          <br />
+          <span className="text-emerald-600 text-sm">
+            ✅ 그룹핑: 헤더 우클릭 → Group by [Column]
+          </span>
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        <LoadingOverlay isLoading={isLoading}>
+          <div className="h-[500px]">
+            <ClientVirtualTable
+              fetchMode="client"
+              data={data}
+              columns={productColumns}
+              isLoading={false} // LoadingOverlay로 처리
+              enablePagination
+              enableGrouping
+              height="100%"
+            />
+          </div>
+        </LoadingOverlay>
+      </CardContent>
+    </Card>
+  );
+}
+
+// ============================================================
+// Pattern 2: Factory Service (Server-Side)
+// ============================================================
+
+function FactoryServiceTable() {
+  const [data, setData] = React.useState<TestProduct[]>([]);
+  const [totalRows, setTotalRows] = React.useState(0);
+  const [isLoading, setIsLoading] = React.useState(true);
+
+  // Table state
+  const [pagination, setPagination] = React.useState<PaginationState>({
+    pageIndex: 0,
+    pageSize: 10,
+  });
+  const [sorting, setSorting] = React.useState<SortingState>([]);
+  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
+  const [globalFilter, setGlobalFilter] = React.useState("");
+
+  // Fetch data on state change
+  React.useEffect(() => {
+    const fetchData = async () => {
+      setIsLoading(true);
+      try {
+        const result = await getProductTableData({
+          pagination,
+          sorting,
+          columnFilters,
+          globalFilter,
+        });
+        setData(result.data);
+        setTotalRows(result.totalRows);
+      } catch (error) {
+        console.error("Failed to fetch products:", error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchData();
+  }, [pagination, sorting, columnFilters, globalFilter]);
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center gap-2">
+          <CardTitle>Pattern 2: Factory Service</CardTitle>
+          <Badge variant="outline">fetchMode=&quot;server&quot;</Badge>
+          <Badge variant="secondary">createTableService</Badge>
+        </div>
+        <CardDescription>
+          <code>createTableService</code>로 서버 액션을 자동 생성합니다.
+          <br />
+          <span className="text-muted-foreground">
+            적합: 단순 CRUD, 마스터 테이블 조회
+          </span>
+          <br />
+          <span className="text-amber-600 text-sm">
+            ⚠️ 그룹핑: 서버 모드에서는 별도 구현 필요 (Pattern 2-B 참고)
+          </span>
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        <LoadingOverlay isLoading={isLoading}>
+          <div className="h-[500px]">
+            <ClientVirtualTable
+              fetchMode="server"
+              data={data}
+              rowCount={totalRows}
+              columns={productColumns}
+              isLoading={false}
+              enablePagination
+              enableGrouping={false}
+              height="100%"
+              pagination={pagination}
+              onPaginationChange={setPagination}
+              sorting={sorting}
+              onSortingChange={setSorting}
+              columnFilters={columnFilters}
+              onColumnFiltersChange={setColumnFilters}
+              globalFilter={globalFilter}
+              onGlobalFilterChange={setGlobalFilter}
+            />
+          </div>
+        </LoadingOverlay>
+      </CardContent>
+    </Card>
+  );
+}
+
+// ============================================================
+// Pattern 2-B: Server-Side Grouping (Context Menu 방식)
+// ============================================================
+
+function ServerGroupingTable() {
+  const [grouping, setGrouping] = React.useState<GroupingState>([]);
+  const [expandedGroups, setExpandedGroups] = React.useState<string[]>([]);
+  const [groups, setGroups] = React.useState<GroupInfo[]>([]);
+  const [flatData, setFlatData] = React.useState<TestProduct[]>([]);
+  const [isGrouped, setIsGrouped] = React.useState(false);
+  const [isLoading, setIsLoading] = React.useState(true);
+  const [totalRows, setTotalRows] = React.useState(0);
+
+  const [pagination, setPagination] = React.useState<PaginationState>({
+    pageIndex: 0,
+    pageSize: 10,
+  });
+
+  // 데이터 페칭
+  React.useEffect(() => {
+    const fetchData = async () => {
+      setIsLoading(true);
+      try {
+        const result = await getProductTableDataWithGrouping(
+          { pagination, grouping },
+          expandedGroups
+        );
+        
+        if ('groups' in result) {
+          setGroups(result.groups);
+          setIsGrouped(true);
+          setFlatData([]);
+        } else {
+          setFlatData(result.data);
+          setTotalRows(result.totalRows);
+          setIsGrouped(false);
+          setGroups([]);
+        }
+      } catch (error) {
+        console.error("Failed to fetch:", error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchData();
+  }, [pagination, grouping, expandedGroups]);
+
+  // 그룹 토글
+  const toggleGroup = (groupKey: string) => {
+    setExpandedGroups(prev => 
+      prev.includes(groupKey) 
+        ? prev.filter(k => k !== groupKey)
+        : [...prev, groupKey]
+    );
+  };
+
+  // 그룹핑 상태 변경 핸들러 (Context Menu에서 호출됨)
+  const handleGroupingChange = React.useCallback((updater: GroupingState | ((old: GroupingState) => GroupingState)) => {
+    const newGrouping = typeof updater === 'function' ? updater(grouping) : updater;
+    setGrouping(newGrouping);
+    setExpandedGroups([]); // 그룹핑 변경 시 확장 상태 초기화
+  }, [grouping]);
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center gap-2">
+          <CardTitle>Pattern 2-B: Server-Side Grouping</CardTitle>
+          <Badge variant="outline">fetchMode=&quot;server&quot;</Badge>
+          <Badge className="bg-emerald-500">GROUP BY</Badge>
+        </div>
+        <CardDescription>
+          서버에서 GROUP BY + 집계 쿼리로 그룹 정보를 조회합니다.
+          <br />
+          <span className="text-emerald-600 text-sm">
+            ✅ 그룹핑: 헤더 우클릭 → Group by [Column] (category, status, isNew만 지원)
+          </span>
+        </CardDescription>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        {/* 현재 그룹핑 상태 표시 */}
+        {grouping.length > 0 && (
+          <div className="flex items-center gap-2 text-sm">
+            <span className="text-muted-foreground">Grouped by:</span>
+            {grouping.map((col) => (
+              <Badge key={col} variant="secondary">
+                {col}
+                <button
+                  className="ml-1 hover:text-destructive"
+                  onClick={() => setGrouping([])}
+                >
+                  ×
+                </button>
+              </Badge>
+            ))}
+          </div>
+        )}
+
+        {/* Content with Loading Overlay */}
+        <LoadingOverlay isLoading={isLoading}>
+          <div className="border rounded-md min-h-[400px] max-h-[500px] overflow-auto">
+            {isGrouped ? (
+              // Grouped View - Custom Rendering
+              <div className="divide-y">
+                {groups.length === 0 ? (
+                  <div className="flex items-center justify-center h-[400px] text-muted-foreground">
+                    No data
+                  </div>
+                ) : (
+                  groups.map((group) => (
+                    <div key={group.groupKey}>
+                      {/* Group Header */}
+                      <button
+                        className="w-full px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors text-left"
+                        onClick={() => toggleGroup(group.groupKey)}
+                      >
+                        {expandedGroups.includes(group.groupKey) ? (
+                          <ChevronDown className="w-4 h-4" />
+                        ) : (
+                          <ChevronRight className="w-4 h-4" />
+                        )}
+                        <span className="font-medium">
+                          {grouping[0]}: <Badge variant="outline">{String(group.groupValue)}</Badge>
+                        </span>
+                        <span className="text-muted-foreground text-sm">
+                          ({group.count} items)
+                        </span>
+                      </button>
+                      
+                      {/* Expanded Rows */}
+                      {expandedGroups.includes(group.groupKey) && group.rows && (
+                        <div className="bg-muted/20 border-t">
+                          <table className="w-full text-sm">
+                            <thead>
+                              <tr className="border-b bg-muted/30">
+                                <th className="px-4 py-2 text-left">ID</th>
+                                <th className="px-4 py-2 text-left">SKU</th>
+                                <th className="px-4 py-2 text-left">Name</th>
+                                <th className="px-4 py-2 text-left">Price</th>
+                                <th className="px-4 py-2 text-left">Stock</th>
+                              </tr>
+                            </thead>
+                            <tbody>
+                              {group.rows.map((row) => (
+                                <tr key={row.id} className="border-b hover:bg-muted/30">
+                                  <td className="px-4 py-2">{row.id}</td>
+                                  <td className="px-4 py-2 font-mono text-xs">{row.sku}</td>
+                                  <td className="px-4 py-2">{row.name}</td>
+                                  <td className="px-4 py-2">
+                                    {new Intl.NumberFormat("en-US", {
+                                      style: "currency",
+                                      currency: "USD",
+                                    }).format(parseFloat(row.price))}
+                                  </td>
+                                  <td className="px-4 py-2">{row.stock}</td>
+                                </tr>
+                              ))}
+                            </tbody>
+                          </table>
+                        </div>
+                      )}
+                    </div>
+                  ))
+                )}
+              </div>
+            ) : (
+              // Normal Table View with Context Menu Grouping
+              <ClientVirtualTable
+                fetchMode="server"
+                data={flatData}
+                rowCount={totalRows}
+                columns={productColumns}
+                enablePagination
+                enableGrouping // Context Menu에서 Group By 옵션 활성화
+                height="400px"
+                pagination={pagination}
+                onPaginationChange={setPagination}
+                // 그룹핑 상태 연결
+                grouping={grouping}
+                onGroupingChange={handleGroupingChange}
+              />
+            )}
+          </div>
+        </LoadingOverlay>
+      </CardContent>
+    </Card>
+  );
+}
+
+// ============================================================
+// Pattern 3: Custom Service (Server-Side with Joins)
+// ============================================================
+
+function CustomServiceTable() {
+  const [data, setData] = React.useState<OrderWithDetails[]>([]);
+  const [totalRows, setTotalRows] = React.useState(0);
+  const [isLoading, setIsLoading] = React.useState(true);
+
+  // Table state
+  const [pagination, setPagination] = React.useState<PaginationState>({
+    pageIndex: 0,
+    pageSize: 10,
+  });
+  const [sorting, setSorting] = React.useState<SortingState>([]);
+  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
+  const [globalFilter, setGlobalFilter] = React.useState("");
+
+  // Fetch data on state change
+  React.useEffect(() => {
+    const fetchData = async () => {
+      setIsLoading(true);
+      try {
+        const result = await getOrderTableData({
+          pagination,
+          sorting,
+          columnFilters,
+          globalFilter,
+        });
+        setData(result.data);
+        setTotalRows(result.totalRows);
+      } catch (error) {
+        console.error("Failed to fetch orders:", error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchData();
+  }, [pagination, sorting, columnFilters, globalFilter]);
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center gap-2">
+          <CardTitle>Pattern 3: Custom Service</CardTitle>
+          <Badge variant="outline">fetchMode=&quot;server&quot;</Badge>
+          <Badge variant="secondary">DrizzleTableAdapter</Badge>
+        </div>
+        <CardDescription>
+          <code>DrizzleTableAdapter</code>를 도구로 사용하여 복잡한 조인 쿼리를 직접 작성합니다.
+          <br />
+          <span className="text-muted-foreground">
+            적합: 여러 테이블 조인, 복잡한 비즈니스 로직
+          </span>
+          <br />
+          <span className="text-amber-600 text-sm">
+            ⚠️ 그룹핑: 가상 컬럼(조인 결과)은 서버 GROUP BY 불가
+          </span>
+        </CardDescription>
+      </CardHeader>
+      <CardContent>
+        <LoadingOverlay isLoading={isLoading}>
+          <div className="h-[500px]">
+            <ClientVirtualTable
+              fetchMode="server"
+              data={data}
+              rowCount={totalRows}
+              columns={orderColumns}
+              isLoading={false}
+              enablePagination
+              enableGrouping={false}
+              height="100%"
+              pagination={pagination}
+              onPaginationChange={setPagination}
+              sorting={sorting}
+              onSortingChange={setSorting}
+              columnFilters={columnFilters}
+              onColumnFiltersChange={setColumnFilters}
+              globalFilter={globalFilter}
+              onGlobalFilterChange={setGlobalFilter}
+            />
+          </div>
+        </LoadingOverlay>
+      </CardContent>
+    </Card>
+  );
+}
+
+// ============================================================
+// Main Page
+// ============================================================
+
+export default function TableV2TestPage() {
+  return (
+    <div className="container py-6 space-y-6">
+      <div>
+        <h1 className="text-3xl font-bold tracking-tight">
+          ClientVirtualTable V2 - 데이터 페칭 패턴 테스트
+        </h1>
+        <p className="text-muted-foreground mt-2">
+          GUIDE.md에 정의된 데이터 페칭 패턴과 그룹핑 처리 방법을 테스트합니다.
+          <br />
+          테스트 전 시딩이 필요합니다: <code className="bg-muted px-1 rounded">npx tsx db/seeds/test-table-v2.ts</code>
+        </p>
+      </div>
+
+      <Tabs defaultValue="pattern1" className="space-y-4">
+        <TabsList className="grid w-full grid-cols-4">
+          <TabsTrigger value="pattern1">
+            1. Client-Side
+          </TabsTrigger>
+          <TabsTrigger value="pattern2">
+            2. Factory Service
+          </TabsTrigger>
+          <TabsTrigger value="pattern2b">
+            2-B. Server Grouping
+          </TabsTrigger>
+          <TabsTrigger value="pattern3">
+            3. Custom Service
+          </TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="pattern1">
+          <ClientSideTable />
+        </TabsContent>
+
+        <TabsContent value="pattern2">
+          <FactoryServiceTable />
+        </TabsContent>
+
+        <TabsContent value="pattern2b">
+          <ServerGroupingTable />
+        </TabsContent>
+
+        <TabsContent value="pattern3">
+          <CustomServiceTable />
+        </TabsContent>
+      </Tabs>
+
+      {/* Summary Table */}
+      <Card>
+        <CardHeader>
+          <CardTitle>패턴별 그룹핑 지원 현황</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <div className="overflow-x-auto">
+            <table className="w-full text-sm">
+              <thead>
+                <tr className="border-b">
+                  <th className="text-left py-2 px-4">패턴</th>
+                  <th className="text-left py-2 px-4">그룹핑 방식</th>
+                  <th className="text-left py-2 px-4">가상 컬럼 지원</th>
+                  <th className="text-left py-2 px-4">비고</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr className="border-b">
+                  <td className="py-2 px-4 font-medium">1. Client-Side</td>
+                  <td className="py-2 px-4">
+                    <Badge className="bg-emerald-500">TanStack Grouping</Badge>
+                  </td>
+                  <td className="py-2 px-4">
+                    <Badge className="bg-emerald-500">✓ 지원</Badge>
+                  </td>
+                  <td className="py-2 px-4 text-muted-foreground">
+                    메모리에서 처리, 전체 데이터 필요
+                  </td>
+                </tr>
+                <tr className="border-b">
+                  <td className="py-2 px-4 font-medium">2. Factory Service</td>
+                  <td className="py-2 px-4">
+                    <Badge variant="outline">미지원</Badge>
+                  </td>
+                  <td className="py-2 px-4">-</td>
+                  <td className="py-2 px-4 text-muted-foreground">
+                    별도 구현 필요 (2-B 참고)
+                  </td>
+                </tr>
+                <tr className="border-b">
+                  <td className="py-2 px-4 font-medium">2-B. Server Grouping</td>
+                  <td className="py-2 px-4">
+                    <Badge className="bg-blue-500">DB GROUP BY</Badge>
+                  </td>
+                  <td className="py-2 px-4">
+                    <Badge variant="destructive">✗ 불가</Badge>
+                  </td>
+                  <td className="py-2 px-4 text-muted-foreground">
+                    serverGroupable 컬럼만 가능
+                  </td>
+                </tr>
+                <tr>
+                  <td className="py-2 px-4 font-medium">3. Custom Service</td>
+                  <td className="py-2 px-4">
+                    <Badge variant="secondary">커스텀 구현</Badge>
+                  </td>
+                  <td className="py-2 px-4">
+                    <Badge variant="secondary">선택적</Badge>
+                  </td>
+                  <td className="py-2 px-4 text-muted-foreground">
+                    쿼리 설계에 따라 다름
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </CardContent>
+      </Card>
+
+      {/* Column Groupability Info */}
+      <Card>
+        <CardHeader>
+          <CardTitle>컬럼별 서버 그룹핑 지원 여부</CardTitle>
+          <CardDescription>
+            <code>meta.serverGroupable</code> 플래그로 DB GROUP BY 가능 여부를 표시합니다.
+            <br />
+            헤더 우클릭 시 &quot;Group by [Column]&quot; 메뉴가 표시됩니다.
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="flex flex-wrap gap-2">
+            {productColumns.map((col) => {
+              if (!('accessorKey' in col)) return null;
+              const meta = col.meta as { serverGroupable?: boolean } | undefined;
+              const isGroupable = meta?.serverGroupable;
+              return (
+                <Badge
+                  key={col.accessorKey as string}
+                  variant={isGroupable ? "default" : "outline"}
+                  className={isGroupable ? "bg-emerald-500" : ""}
+                >
+                  {col.accessorKey as string}
+                  {isGroupable && " ✓"}
+                </Badge>
+              );
+            })}
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
-- 
cgit v1.2.3


From 211b5dbdda47c4bb19f3ebc274253b21e1b81bc4 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 8 Dec 2025 01:46:14 +0900
Subject: (김준회) 서버사이드 페칭 예시 소팅 오류 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .cursor/rules/evcp-project-rules.mdc               | 22 +++++
 .cursor/rules/table-guide.mdc                      |  4 +
 app/[lng]/test/table-v2/actions.ts                 | 29 ++++++-
 app/[lng]/test/table-v2/page.tsx                   | 11 ++-
 components/client-table-v2/GUIDE-v2.md             | 93 ++++++++++++++++++++++
 components/client-table-v2/GUIDE-v3-ko.md          | 92 +++++++++++++++++++++
 components/client-table-v2/GUIDE-v3.md             | 93 ++++++++++++++++++++++
 components/client-table-v2/client-table-preset.tsx |  3 +
 8 files changed, 343 insertions(+), 4 deletions(-)
 create mode 100644 .cursor/rules/evcp-project-rules.mdc
 create mode 100644 .cursor/rules/table-guide.mdc
 create mode 100644 components/client-table-v2/GUIDE-v2.md
 create mode 100644 components/client-table-v2/GUIDE-v3-ko.md
 create mode 100644 components/client-table-v2/GUIDE-v3.md

(limited to 'app/[lng]/test/table-v2/page.tsx')

diff --git a/.cursor/rules/evcp-project-rules.mdc b/.cursor/rules/evcp-project-rules.mdc
new file mode 100644
index 00000000..cbd3e44d
--- /dev/null
+++ b/.cursor/rules/evcp-project-rules.mdc
@@ -0,0 +1,22 @@
+---
+alwaysApply: true
+---
+1. tech stacks: nextjs 15, postgres 17 with drizzle-orm, shadcn-ui, react 18.3.1 full stack
+2. user info: Intermediate English. Hardcoded text and comment can be written in English.
+
+specific:
+- Above Nextjs 15, exported server action functions should be async function.
+- Do not use shadcn ui ScrollArea Function in Dialog component. (It has error now.)
+- Most packages are already installed. If package installation required, check package.json file first.
+
+design:
+- If you can suppose some design patterns for solve the problem in prompt, notify it.
+- Check component/common/* for shared components.
+For these tasks, instruct the user rather than doing 
+
+limit:
+- About CLI task, just notify. User will operate CLI. For example, 'npx drizzle-kit generate & migrate', 'npm run dev'.
+- You can't read and edit .env.* files. 
+
+limit-solution:
+- For limited tasks, instruct the user rather than doing them yourself.
\ No newline at end of file
diff --git a/.cursor/rules/table-guide.mdc b/.cursor/rules/table-guide.mdc
new file mode 100644
index 00000000..7b240413
--- /dev/null
+++ b/.cursor/rules/table-guide.mdc
@@ -0,0 +1,4 @@
+---
+alwaysApply: false
+---
+If table management is required, see @/components/client-table-v2/GUIDE-v3.md
\ No newline at end of file
diff --git a/app/[lng]/test/table-v2/actions.ts b/app/[lng]/test/table-v2/actions.ts
index e1737083..f5fd5f66 100644
--- a/app/[lng]/test/table-v2/actions.ts
+++ b/app/[lng]/test/table-v2/actions.ts
@@ -5,7 +5,7 @@ import { testProducts, testOrders, testCustomers } from "@/db/schema/test-table-
 import { createTableService } from "@/components/client-table-v2/adapter/create-table-service";
 import { DrizzleTableState } from "@/components/client-table-v2/adapter/drizzle-table-adapter";
 import { productColumnDefs, OrderWithDetails, ServerColumnMeta } from "./column-defs";
-import { count, eq, desc, sql, asc } from "drizzle-orm";
+import { SQL, count, eq, desc, sql, asc } from "drizzle-orm";
 import { TestProduct } from "@/db/schema/test-table-v2";
 
 // ============================================================
@@ -182,6 +182,31 @@ export async function getOrderTableData(tableState: DrizzleTableState): Promise<
   const limit = pageSize;
   const offset = pageIndex * pageSize;
 
+  // Build ORDER BY clause based on sorting state
+  const orderByClauses =
+    tableState.sorting?.reduce<SQL<unknown>[]>((clauses, sort) => {
+      const columnMap: Record<string, any> = {
+        id: testOrders.id,
+        orderNumber: testOrders.orderNumber,
+        quantity: testOrders.quantity,
+        unitPrice: testOrders.unitPrice,
+        totalAmount: testOrders.totalAmount,
+        status: testOrders.status,
+        orderedAt: testOrders.orderedAt,
+        customerName: testCustomers.name,
+        customerEmail: testCustomers.email,
+        customerTier: testCustomers.tier,
+        productName: testProducts.name,
+        productSku: testProducts.sku,
+      };
+
+      const column = columnMap[sort.id];
+      if (!column) return clauses;
+
+      clauses.push(sort.desc ? desc(column) : asc(column));
+      return clauses;
+    }, []) ?? [];
+
   // 커스텀 조인 쿼리 작성
   const data = await db
     .select({
@@ -203,7 +228,7 @@ export async function getOrderTableData(tableState: DrizzleTableState): Promise<
     .from(testOrders)
     .leftJoin(testCustomers, eq(testOrders.customerId, testCustomers.id))
     .leftJoin(testProducts, eq(testOrders.productId, testProducts.id))
-    .orderBy(desc(testOrders.orderedAt))
+    .orderBy(...(orderByClauses.length > 0 ? orderByClauses : [desc(testOrders.orderedAt)]))
     .limit(limit)
     .offset(offset);
 
diff --git a/app/[lng]/test/table-v2/page.tsx b/app/[lng]/test/table-v2/page.tsx
index e7fb5bdd..65c0ee1d 100644
--- a/app/[lng]/test/table-v2/page.tsx
+++ b/app/[lng]/test/table-v2/page.tsx
@@ -99,6 +99,8 @@ function ClientSideTable() {
               enablePagination
               enableGrouping
               height="100%"
+              enableUserPreset={true}
+              tableKey="test-table-v2-pattern1"
             />
           </div>
         </LoadingOverlay>
@@ -188,6 +190,8 @@ function FactoryServiceTable() {
               onColumnFiltersChange={setColumnFilters}
               globalFilter={globalFilter}
               onGlobalFilterChange={setGlobalFilter}
+              enableUserPreset={true}
+              tableKey="test-table-v2-pattern-2-A"
             />
           </div>
         </LoadingOverlay>
@@ -208,6 +212,7 @@ function ServerGroupingTable() {
   const [isGrouped, setIsGrouped] = React.useState(false);
   const [isLoading, setIsLoading] = React.useState(true);
   const [totalRows, setTotalRows] = React.useState(0);
+  const [sorting, setSorting] = React.useState<SortingState>([]);
 
   const [pagination, setPagination] = React.useState<PaginationState>({
     pageIndex: 0,
@@ -220,7 +225,7 @@ function ServerGroupingTable() {
       setIsLoading(true);
       try {
         const result = await getProductTableDataWithGrouping(
-          { pagination, grouping },
+          { pagination, grouping, sorting },
           expandedGroups
         );
         
@@ -242,7 +247,7 @@ function ServerGroupingTable() {
     };
 
     fetchData();
-  }, [pagination, grouping, expandedGroups]);
+  }, [pagination, grouping, sorting, expandedGroups]);
 
   // 그룹 토글
   const toggleGroup = (groupKey: string) => {
@@ -374,6 +379,8 @@ function ServerGroupingTable() {
                 height="400px"
                 pagination={pagination}
                 onPaginationChange={setPagination}
+                sorting={sorting}
+                onSortingChange={setSorting}
                 // 그룹핑 상태 연결
                 grouping={grouping}
                 onGroupingChange={handleGroupingChange}
diff --git a/components/client-table-v2/GUIDE-v2.md b/components/client-table-v2/GUIDE-v2.md
new file mode 100644
index 00000000..930123fb
--- /dev/null
+++ b/components/client-table-v2/GUIDE-v2.md
@@ -0,0 +1,93 @@
+# ClientVirtualTable V2 — Server Fetching Guide
+
+This guide focuses on `fetchMode="server"` usage (Tabs 2, 2-B, 3 in `/[lng]/test/table-v2`). Client mode is unchanged from `GUIDE.md`.
+
+## Core Concepts
+- `fetchMode="server"` sets `manualPagination|manualSorting|manualFiltering|manualGrouping` to true. The table **renders what the server returns**; no client-side sorting/filtering/pagination is applied.
+- You must control table state (pagination, sorting, filters, grouping, globalFilter) in the parent and refetch on change.
+- Provide `rowCount` (and optionally `pageCount`) so the pagination footer is accurate.
+- Export uses the current row model; in server mode it only exports the loaded page unless you fetch everything yourself.
+
+## Minimal Wiring (Factory Service)
+```tsx
+const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
+const [sorting, setSorting] = useState([]);
+const [columnFilters, setColumnFilters] = useState([]);
+const [globalFilter, setGlobalFilter] = useState("");
+const [data, setData] = useState([]);
+const [rowCount, setRowCount] = useState(0);
+const [loading, setLoading] = useState(false);
+
+useEffect(() => {
+  const run = async () => {
+    setLoading(true);
+    const res = await getTableData({
+      pagination,
+      sorting,
+      columnFilters,
+      globalFilter,
+    });
+    setData(res.data);
+    setRowCount(res.totalRows);
+    setLoading(false);
+  };
+  run();
+}, [pagination, sorting, columnFilters, globalFilter]);
+
+<ClientVirtualTable
+  fetchMode="server"
+  data={data}
+  rowCount={rowCount}
+  columns={columns}
+  isLoading={loading}
+  enablePagination
+  pagination={pagination}
+  onPaginationChange={setPagination}
+  sorting={sorting}
+  onSortingChange={setSorting}
+  columnFilters={columnFilters}
+  onColumnFiltersChange={setColumnFilters}
+  globalFilter={globalFilter}
+  onGlobalFilterChange={setGlobalFilter}
+/>
+```
+
+## Using `createTableService` (Pattern 2)
+- Import `createTableService` in a server action and pass `columns` (accessorKey-based) plus schema/db.
+- The adapter maps `sorting`, `columnFilters`, `globalFilter`, `pagination` to Drizzle query parts.
+- Returned shape: `{ data, totalRows, pageCount }`. Always forward `totalRows` to the client.
+
+## Custom Service (Pattern 3)
+- Build custom joins manually; still read `tableState` for pagination/sorting/filtering if you need them.
+- For sorting: map `tableState.sorting` IDs to your joined columns; provide a default order if none is set.
+- Grouping in custom services requires manual implementation (see `getOrderTableDataGroupedByStatus` pattern).
+
+## Server Grouping (Pattern 2-B)
+- Only columns marked `meta.serverGroupable` in server column defs should be used.
+- Group headers are fetched via DB `GROUP BY`; expanded rows are fetched per group.
+- When grouping is active, the table may render a custom grouped view instead of the virtual table; ensure your fetcher returns either `{ groups }` or `{ data, totalRows }`.
+
+## Presets in Server Mode
+- Presets store: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize.
+- Loading a preset triggers the table’s `set*` APIs; parent `on*Change` handlers refetch with the restored state.
+- The component resets pageIndex to 0 when applying a preset to avoid out-of-range requests after pageSize changes.
+- Use unique `tableKey` per screen to avoid clashing presets across pages.
+
+## Common Pitfalls
+- Forgetting `rowCount` → pagination shows wrong totals.
+- Not reacting to `sorting`/`filters`/`grouping` changes in your effect → UI toggles with no data change.
+- Mapping `sorting` IDs to columns incorrectly in custom services → server ignores the sort.
+- Mixing client-side models with server mode: do not enable client `getSortedRowModel`/`getFilteredRowModel` for server fetches (the component already skips them when `fetchMode="server"`).
+
+## Feature Matrix (Server Mode)
+- Sorting: Supported; must be implemented in the server fetcher.
+- Filtering: Supported; column filters/global filter forwarded; implement in server.
+- Pagination: Supported; manual; provide `rowCount`.
+- Grouping: Client grouping is off in server mode; implement via server `GROUP BY` or custom grouped view.
+- Column show/hide, pinning, reorder: Client-side only; state is preserved and sent to presets but does not affect server queries unless you opt to read it.
+- Export: Exports the currently loaded rows; fetch all data yourself for full exports.
+
+## Debug Checklist
+- Confirm `fetchMode="server"` and `rowCount` are set.
+- Verify the parent effect depends on `pagination`, `sorting`, `columnFilters`, `globalFilter`, and (if used) `grouping`.
+- In custom services, console/log the incoming `tableState` to confirm the UI is sending the intended state.
diff --git a/components/client-table-v2/GUIDE-v3-ko.md b/components/client-table-v2/GUIDE-v3-ko.md
new file mode 100644
index 00000000..9ec71065
--- /dev/null
+++ b/components/client-table-v2/GUIDE-v3-ko.md
@@ -0,0 +1,92 @@
+# ClientVirtualTable V3 가이드 (한국어)
+
+`components/client-table-v2` 테이블 컴포넌트와 `fetchMode="server"` 사용 시 주의점을 정리했습니다.
+
+## 모듈 맵
+- `client-virtual-table.tsx`: 코어 테이블(가상 스크롤, 컬럼 DnD, 핀/숨김, 프리셋, 툴바, 페이지네이션).
+- `client-table-column-header.tsx`: 헤더 셀(정렬 토글, 필터 UI, 컨텍스트 메뉴: 핀/숨김/그룹/재정렬).
+- `client-table-toolbar.tsx` (client-table): 검색, 내보내기, 뷰 옵션, 프리셋 엔트리.
+- `client-table-view-options.tsx` (client-table): 컬럼 표시/숨김 토글.
+- `client-table-filter.tsx`: 컬럼 필터 UI(text/select/boolean).
+- `client-table-preset.tsx`: `tableKey`+사용자별 프리셋 저장/불러오기/삭제.
+- 기타: `export-utils`, `import-utils`, `ClientDataTablePagination`(client-data-table).
+- 서버 헬퍼: `adapter/create-table-service.ts`, `adapter/drizzle-table-adapter.ts`.
+- 타입: `types.ts`, `preset-types.ts`.
+
+## 핵심 동작 (ClientVirtualTable)
+- 가상 스크롤: `height` 필수, `estimateRowHeight` 기본 40.
+- DnD: 컬럼 재배치, 핀 섹션 간 이동 시 핀 상태 동기화.
+- 핀/숨김/순서: 클라이언트 상태(`columnVisibility`, `columnPinning`, `columnOrder`).
+- 정렬/필터/페이지네이션/그룹핑
+  - `fetchMode="client"`: TanStack 모델 사용.
+  - `fetchMode="server"`: manual 플래그 on, 클라이언트 모델 skip → **서버가 정렬/필터/페이징된 결과를 반환해야 함**.
+- 내보내기: 현재 렌더된 행 기준. 서버 모드에서 전체 내보내기는 직접 `onExport`로 구현 필요.
+- 프리셋: `enableUserPreset`+`tableKey` 설정 시 표시. 불러올 때 pageIndex를 0으로 리셋해 서버 모드에서 범위 오류 방지.
+
+## 주요 Props
+- `fetchMode`: `"client"` | `"server"` (기본 `"client"`).
+- 데이터: `data`, `rowCount?`, `pageCount?`.
+- 상태/핸들러:
+  - 페이지: `pagination`, `onPaginationChange`, `enablePagination`, `manualPagination?`.
+  - 정렬: `sorting`, `onSortingChange`.
+  - 필터: `columnFilters`, `onColumnFiltersChange`, `globalFilter`, `onGlobalFilterChange`.
+  - 그룹핑: `grouping`, `onGroupingChange`, `expanded`, `onExpandedChange`, `enableGrouping`.
+  - 표시/핀/순서: `columnVisibility`, `columnPinning`, `columnOrder` 및 각 onChange.
+  - 선택: `enableRowSelection`, `enableMultiRowSelection`, `rowSelection`, `onRowSelectionChange`.
+- UX: `actions`, `customToolbar`, `enableExport`, `onExport`, `renderHeaderVisualFeedback`, `getRowClassName`, `onRowClick`.
+- 프리셋: `enableUserPreset`, `tableKey`.
+- 메타: `meta`, `getRowId`.
+
+## 서버 페칭 패턴
+### 패턴 1: 클라이언트 모드
+- `fetchMode="client"`, 전체 데이터 전달. 정렬/필터/그룹핑은 브라우저에서 처리.
+
+### 패턴 2: Factory Service (`createTableService`)
+- 서버 액션: `createTableService({ db, schema, columns, defaultWhere?, customQuery? })`.
+- 어댑터가 `sorting`, `columnFilters`, `globalFilter`, `pagination`, `grouping`을 Drizzle `where/orderBy/limit/offset/groupBy`로 변환.
+- 반환 `{ data, totalRows, pageCount }` → 클라이언트에서 `rowCount` 설정 필수.
+- 클라이언트: `pagination/sorting/columnFilters/globalFilter` 제어 후 deps로 `useEffect` 재호출.
+
+### 패턴 2-B: 서버 그룹핑
+- `getProductTableDataWithGrouping` 예시: `grouping` 없으면 일반 페칭, 있으면 DB `GROUP BY` 후 `{ groups }` 반환.
+- 서버 그룹핑 가능한 컬럼(`meta.serverGroupable`)만 사용.
+- 그룹 확장 시 그룹 키별 하위 행을 추가 조회, 그룹 변경 시 확장 상태 초기화.
+- 그룹뷰 렌더 시 가상 테이블 대신 커스텀 블록을 사용할 수 있음.
+
+### 패턴 3: 커스텀 서비스
+- 조인/파생 컬럼용. `tableState`를 읽어 정렬 ID를 조인 컬럼에 매핑, 정렬 없을 때 기본 정렬 제공.
+- 필터/글로벌 필터는 직접 구현해야 함.
+- 그룹핑도 수동 구현(`getOrderTableDataGroupedByStatus` 참고).
+
+## 상태 → 쿼리 매핑 (서버)
+- 정렬: `tableState.sorting`(id, desc) → DB 컬럼 매핑, 모르는 id는 무시.
+- 필터: 텍스트(ilike), 불리언, 숫자, 범위[min,max], 다중선택(IN) 지원.
+- 글로벌 필터: 매핑된 컬럼 OR ilike.
+- 페이지: pageIndex/pageSize → limit/offset, `rowCount` 반환.
+- 그룹핑: 지원 컬럼만 `GROUP BY`.
+
+## 프리셋 (서버 호환)
+- 저장: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize.
+- 불러오기: `table.set*` 호출 + pageIndex 0 리셋 → 상위 `on*Change` 핸들러에서 재페칭.
+- 화면별 고유 `tableKey` 사용 권장. 세션 필요.
+
+## 기능 매트릭스 (서버 모드)
+- 정렬: 지원 (서버 구현 필요)
+- 필터: 지원 (서버 구현 필요)
+- 페이지네이션: 지원 (manual, `rowCount` 필요)
+- 그룹핑: 자동 미지원, 서버 그룹핑 또는 커스텀 뷰로 구현
+- 컬럼 숨김/핀/순서: 클라이언트 전용(시각용), 서버 쿼리에 자동 반영 안 함
+- 내보내기: 로드된 행만; 전체 내보내기는 커스텀 `onExport` 필요
+
+## 구현 팁
+- `fetchMode="server"`일 때 `rowCount` 꼭 설정.
+- `pagination/sorting/columnFilters/globalFilter/(grouping)` 변경 시마다 재페칭.
+- 정렬 없을 때 서버 기본 정렬을 지정.
+- 그룹 변경 시 확장 상태 초기화.
+- `height`를 항상 지정(가상 스크롤 컨테이너 필요).
+
+## 빠른 예시
+- 클라이언트: `fetchMode="client"`, 전체 데이터 전달, 그룹핑 옵션 사용 가능.
+- Factory 서버: `fetchMode="server"`, `createTableService`, 제어형 상태 + `rowCount`.
+- 서버 그룹핑: `grouping`에 따라 `{ groups }` vs `{ data }` 반환, `serverGroupable` 컬럼만 허용.
+- 커스텀 조인: 정렬 ID 직접 매핑, 필터/글로벌 직접 적용, `rowCount` 반환.
diff --git a/components/client-table-v2/GUIDE-v3.md b/components/client-table-v2/GUIDE-v3.md
new file mode 100644
index 00000000..21a1217d
--- /dev/null
+++ b/components/client-table-v2/GUIDE-v3.md
@@ -0,0 +1,93 @@
+# ClientVirtualTable V3 Guide
+
+This guide documents the table components in `components/client-table-v2`, with an emphasis on server fetching (`fetchMode="server"`) and how supporting components fit together.
+
+## Module Map
+- `client-virtual-table.tsx`: Core table (virtualized, DnD columns, pin/hide, presets hook point, toolbar, pagination).
+- `client-table-column-header.tsx`: Header cell with sort toggle, filter UI, context menu (hide/pin/group/reorder hook).
+- `client-table-toolbar.tsx` (from `components/client-table`): Search box, export button, view options, preset entry point.
+- `client-table-view-options.tsx` (from `components/client-table`): Column visibility toggles.
+- `client-table-filter.tsx`: Column filter UI (text/select/boolean).
+- `client-table-preset.tsx`: Save/load/delete presets per `tableKey` + user.
+- `client-table-save-view.tsx`, `client-table-preset.tsx`, `client-table-toolbar.tsx`: Preset and view controls.
+- `client-virtual-table` dependencies: `ClientDataTablePagination` (`components/client-data-table`), `export-utils`, `import-utils`.
+- Server helpers: `adapter/create-table-service.ts`, `adapter/drizzle-table-adapter.ts`.
+- Types: `types.ts`, `preset-types.ts`.
+
+## Core Behaviors (ClientVirtualTable)
+- Virtualization: `height` is required; `estimateRowHeight` defaults to 40.
+- Drag & Drop: Columns reorder across pin sections; drag between pin states updates pinning.
+- Pin/Hide/Reorder: Managed client-side; state is exposed via `columnVisibility`, `columnPinning`, `columnOrder`.
+- Sorting/Filtering/Pagination/Grouping:
+  - `fetchMode="client"`: uses TanStack models (`getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel`, etc.).
+  - `fetchMode="server"`: sets manual flags true and skips client models; **server must return already-sorted/filtered/paged data**.
+- Export: Uses current row model; in server mode it exports only the loaded rows unless you supply all data yourself via `onExport`.
+- Presets: When `enableUserPreset` and `tableKey` are set, toolbar shows the preset control; loading a preset resets pageIndex to 0 to avoid invalid pages on server mode.
+
+## Key Props (ClientVirtualTable)
+- `fetchMode`: `"client"` | `"server"` (default `"client"`).
+- Data: `data`, `rowCount?`, `pageCount?`.
+- State + handlers (controlled or uncontrolled):
+  - Pagination: `pagination`, `onPaginationChange`, `enablePagination`, `manualPagination?`.
+  - Sorting: `sorting`, `onSortingChange`.
+  - Filters: `columnFilters`, `onColumnFiltersChange`, `globalFilter`, `onGlobalFilterChange`.
+  - Grouping: `grouping`, `onGroupingChange`, `expanded`, `onExpandedChange`, `enableGrouping`.
+  - Visibility/Pinning/Order: `columnVisibility`, `onColumnVisibilityChange`, `columnPinning`, `onColumnPinningChange`, `columnOrder`, `onColumnOrderChange`.
+  - Selection: `enableRowSelection`, `enableMultiRowSelection`, `rowSelection`, `onRowSelectionChange`.
+- UX: `actions`, `customToolbar`, `enableExport`, `onExport`, `renderHeaderVisualFeedback`, `getRowClassName`, `onRowClick`.
+- Presets: `enableUserPreset`, `tableKey`.
+- Meta: `meta`, `getRowId`.
+
+## Server Fetching Patterns
+### Pattern 1: Client-Side (baseline)
+- `fetchMode="client"`, pass full dataset; TanStack handles sorting/filtering/grouping locally.
+
+### Pattern 2: Factory Service (`createTableService`)
+- Server action: `createTableService({ db, schema, columns, defaultWhere?, customQuery? })`.
+- The adapter maps `sorting`, `columnFilters`, `globalFilter`, `pagination`, `grouping` → Drizzle `where`, `orderBy`, `limit`, `offset`, `groupBy`.
+- Returns `{ data, totalRows, pageCount }`; always forward `totalRows` to the client and wire `rowCount`.
+- Client wiring: control `pagination`, `sorting`, `columnFilters`, `globalFilter`; refetch in `useEffect` on those deps.
+
+### Pattern 2-B: Server Grouping
+- Uses `getProductTableDataWithGrouping` sample: if `grouping` is empty → normal server fetch; else returns `{ groups }` built from DB `GROUP BY`.
+- Columns must be marked `meta.serverGroupable` in server column defs.
+- Expanded groups fetch child rows per group key; grouping change clears expanded state.
+- UI may render a custom grouped view (not the virtual table) when grouped.
+
+### Pattern 3: Custom Service
+- For joins/derived columns: read `tableState` and manually map `sorting` IDs to joined columns; supply a default order when no sort is present.
+- Filtering/global filter are not automatic—implement them if needed.
+- Grouping is manual; see `getOrderTableDataGroupedByStatus` pattern for a grouped response shape.
+
+## State → Query Mapping (Server)
+- Sorting: `tableState.sorting` (id, desc) → map to DB columns; ignore unknown ids.
+- Filters: `columnFilters` supports text (ilike), boolean, number, range `[min,max]`, multi-select (IN).
+- Global filter: ilike OR across mapped columns.
+- Pagination: pageIndex/pageSize → limit/offset; return `rowCount`.
+- Grouping: `grouping` → `GROUP BY` for supported columns only.
+
+## Presets (Server-Friendly)
+- Saved keys: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize.
+- On load: applies `table.set*` and resets pageIndex to 0; parent `on*Change` handlers should trigger refetch.
+- Use unique `tableKey` per screen to avoid collisions; requires authenticated session.
+
+## Feature Matrix (Server Mode)
+- Sorting: Yes—server implemented.
+- Filtering: Yes—server implemented.
+- Pagination: Yes—manual; provide `rowCount`.
+- Grouping: Not automatic; implement via server grouping or custom grouped view.
+- Column hide/pin/reorder: Client-only (visual); does not change server query unless you opt to read it.
+- Export: Only current rows unless you provide `onExport` with full data.
+
+## Implementation Tips
+- Always set `rowCount` when `fetchMode="server"`.
+- Refetch on `pagination`, `sorting`, `columnFilters`, `globalFilter`, and `grouping` (if used).
+- Provide a default sort on the server when `sorting` is empty.
+- Reset `expanded` or group expand state when grouping changes in server grouping flows.
+- Ensure `height` is set; virtualization needs a scroll container.
+
+## Quick Examples
+- Client: `fetchMode="client"` with `data` = full list; optional grouping enabled.
+- Factory server: `fetchMode="server"`, `createTableService` action, controlled state with `rowCount`.
+- Server grouping: `grouping` drives `{ groups }` vs `{ data }` response; only `serverGroupable` columns allowed.
+- Custom join: Manually map `sorting` ids; apply filters/global; return `rowCount`.
diff --git a/components/client-table-v2/client-table-preset.tsx b/components/client-table-v2/client-table-preset.tsx
index 64930e7a..21486c9b 100644
--- a/components/client-table-v2/client-table-preset.tsx
+++ b/components/client-table-v2/client-table-preset.tsx
@@ -108,6 +108,9 @@ export function ClientTablePreset<TData>({
     if (s.columnOrder) table.setColumnOrder(s.columnOrder);
     if (s.grouping) table.setGrouping(s.grouping);
     if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize);
+    // Reset page index to avoid loading an out-of-range page after applying a preset,
+    // which is especially important in server-mode pagination.
+    table.setPageIndex(0);
     
     toast.success(`Preset "${preset.name}" loaded`);
   };
-- 
cgit v1.2.3

